【译】用Java生成字符画

原文:Ascii art generator in Java
github源码地址

ASCII码字符画艺术是一种利用ASCII码标准中的可打印字符来产生视觉艺术效果的技术,它的存在在历史上是有意义的,当时的打印机还无法打印图片,而且当时在邮件中嵌入图像还无法实现,所以它也用于邮件中。在本文中,我将为你呈现一个用Java实现的、可以配置字体和对比度的ASCII码字符画生成器程序。因为这个程序是我在周末用几个小时搞定的,还不完美,但这是一个有意思的实验,在下面你可以看到实现代码,我将解释它的工作原理。

算法

算法的思路很简单。首先,我们将程序中要用到的每一个字符转化成一张图片,并缓存它。然后,我们遍历原始图像,对于每个字符大小的图片块,找出最佳匹配的字符。为了实现这一点,我们首先对原始图像做一些预处理:我们先将图像转化为灰度图,然后让其通过一个阈值滤波器,这样我们就得到一个黑白色的图像,我们可以将其与每个字符对比并计算差值。接着,对每个图片块选取最相似的字符,一直进行下去,直到整个图像都转换完成。此外,我们还可以根据需要调整阈值大小来调整对比度,增强最终的效果。
为了实现这一点,一个非常简单的方法是将红、绿、蓝的值都设置成三种颜色的平均值:
红=绿=蓝=(红+绿+蓝)/3
如果这个值低于阈值,我们就将它设置成白色,否则我们将其设置成黑色。最后,我们以像素为单位将图像与每个字符进行比较并计算出平均误差。如下面的图片和代码片段所示。
eiffel

1
2
3
4
5
6
7
8
9
10
11
12
int r1 = (charPixel >> 16) & 0xFF;
int g1 = (charPixel >> 8) & 0xFF;
int b1 = charPixel & 0xFF;

int r2 = (sourcePixel >> 16) & 0xFF;
int g2 = (sourcePixel >> 8) & 0xFF;
int b2 = sourcePixel & 0xFF;

int thresholded = (r2 + g2 + b2) / 3 < THRESHOLD ? 0 : 255;

error = Math.sqrt((r1 - thresholded) * (r1 - thresholded) +
(g1 - thresholded) * (g1 - thresholded) + (b1 - thresholded) * (b1 - thresholded));

因为颜色是存储在单个整数中,所以我们首先提取单个颜色成分并执行上面我解释过的计算,另一个挑战是准确地测量字符尺寸,并以它们为中心作图。在试验了多种方法之后,我最终发现这个比较好的方法:

1
2
3
4
Rectangle rect = new TextLayout(Character.toString((char) i), fm.getFont(), 
fm.getFontRenderContext()).getOutline(null).getBounds();

g.drawString(character, 0, (int) (rect.getHeight() - rect.getMaxY()));

你可以在Github上下载完整的源代码。
下面是一些使用不同字体尺寸和阈值的例子:
part1_9pic


Part 2

由于上一篇博客谈到的ASCII码字符画生成器(在Github上查看源码)收到了很多反馈,我决定继续这个项目,如果大家很喜欢的话我再增加几个feature。我重新设计了程序的主要部分,让它更具扩展性,易于采用不同的算法,生成不同的输出等等。在这一部分,我会展示这个项目的新的架构,让你可以更容易地集成到自己的代码中,按照你的需要扩展它。

架构:

architecture

AsciiImgCache

在渲染ASCII码字符之前,实例化这个类是必要的。它将字体和字符数组作为参数,为每个字母生成图片,如果你不想麻烦,代码中有默认的字符数组。
假如你好奇:

1
2
private static final char[] defaultCharacters = 
"$@B%8&WM#*oahkbdpqwmZO0QLCJUYXzcvunxrjft/\\|()1{}[]?-_+~<>i!lI;:,\"^`'. "

例子:

1
2
3
4
5
6
7
// use only '/' '\' and ' '
AsciiImgCache mediumBlackAndWhiteCache = AsciiImgCache.
create(new Font("Courier", Font.BOLD, 10), new char[] {'\\', ' ', '/'});

// use default list
AsciiImgCache largeFontCache = AsciiImgCache.
create(new Font("Courier",Font.PLAIN, 16));

BestCharacterFitStrategy

这个类是用来确定原图片与每个字符有多接近的算法的抽象。它有一个方法:

1
float calculateError(final GrayscaleMatrix character, final GrayscaleMatrix tile);

这个方法比较两张图片,返回一个浮点型的误差。每个字母都将与图片比较,最小误差的那个将被选中,返回。目前这个类中有两个可用的方法:ColorSquareErrorFitStrategy和 StructuralSimilarityFitStrategy。

ColorSquareErrorFitStrategy

这个很容易理解,它比较每一个像素点,计算每个灰度的均方差,用数学的语言来说就是:
$MSE=\frac{1}{n} \sum\limits_{1}\limits^n(C_i-T_i)^2$
n是像素点的个数,C和T分别是字符和分割的图片。

StructuralSimilarityFitStrategy

图像相似性指标(The structural similarity (SSIM) index algorithm)要求重现人类的感知,它的目标是提高类似与MSE的传统算法。我不会给出关于它机理的更多细节,如果你有兴趣,你可以在Wikipedia上面了解更多。我实验了一下,貌似实现了一个更优的版本。

AsciiConverter

这是算法的核心,它包括了所有分割原图片、匹配最佳字符的逻辑的实现。但是,它不包含输出ASCII字符画–它需要子类的实现。目前有两种实现:AsciiToImageConverter 和 AsciiToStringConverter,你可能猜到了,图片是用字符串输出产生的。

使用示例:

既然talk is cheap,我就展示一下产生ASCII码图片的大致流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// initialize cache
AsciiImgCache cache = AsciiImgCache.create(new Font("Courier",Font.BOLD, 6));

// load image
BufferedImage portraitImage = ImageIO.read(new File("image.png"));

// initialize converters
AsciiToImageConverter imageConverter =
new AsciiToImageConverter(cache, new ColorSquareErrorFitStrategy());
AsciiToStringConverter stringConverter =
new AsciiToStringConverter(cache, new StructuralSimilarityFitStrategy());

// image output
ImageIO.write(imageConverter.convertImage(portraitImage), "png",
new File("ascii_art.png"));
// string converter, output to console
System.out.println(stringConverter.convertImage(portraitImage));

这有一些根据不同参数产生的实例图像:
原始图像
原始图像

16磅字体,MSE
16磅字体,MSE

16磅字体,SSIM
16磅字体,SSIM

3字符10磅字体,MSE
3字符16磅字体,MSE

3字符10磅字体,SSIM
3字符16磅字体,SSIM

6磅字体,MSE
6磅字体,MSE

6磅字体,SSIM
6磅字体,SSIM

进一步的工作

现在脑袋里有一些想法:

  • 搜索并尝试更多的图像比较的算法
  • 预处理图像,获得更好的结果(提高对比度,检测边缘等等)
  • 并行地进行图像处理,提高性能,尝试一下看看是否需要
  • 增加更多的转换结果(如html文件的输出)
  • 增加多种颜色字符的输出
  • 增加测试单元

如果你想改善代码,或者发现了代码的bug,在博客里评论或者到Github来贡献代码吧。